#!/usr/bin/python
"""
 Title: Scheduler.py
 Author: Matthew Tanner
 Project: Facultimes
 This is an Oregon State University project built as a requirement for CS319. 
 The project is a collaboration between Matthew Tanner, Arnold Baghomian, and
 Joseph Helgeson. 
 """

from datetime import datetime, timedelta
from Scraper import Scraper
from copy import deepcopy
from os.path import isfile
import sqlite3 as lite
from xml.etree import ElementTree
from CalendarTool import CalendarTool
import sys
import errno, os

class Scheduler(object):
	"""The Scheduler class provides scheduling information.

	It is meant to help OSU faculty in arranging meeting times amongst 
	themselves. It's base functionality is to take a list of instructors and 
	a time window, and return the following solution:
	A list of times windows in which all users are available to meet
	A list of users who are available to meet throughout the given window.

    Attributes: 
    	TIME_FORMAT: Constant string for use in datetime conversion.
    	CONFIG_FILE_PATH: String constant for indicating path to config file.
    	error: String representing error code set if errors are caught.
    """
	TIME_FORMAT = "%m/%d/%Y %H:%M"
	CONFIG_FILE_PATH = "facul-config.xml"

	def loadConfigData(self):
		"""Loads config data from xml file

		Retrieves config options and username/Calendar ID pairs.

		Args:
			None
		Returns:
			0 on success
			-1 on Error (sets Scheduler.error with error code)
		"""
		# open config file
		try:
			f = open(Scheduler.CONFIG_FILE_PATH, 'r')
		except IOError as err:
			self.error = ("Error opening config file: %s: %s" % (Scheduler.CONFIG_FILE_PATH, err.args[1]))
			return -1

		
		try:
			# parse xml
			tree = ElementTree.parse(f)
			# get business hours
			if not hasattr(self, 'businessHours'):
				self.businessHours = {}
				hours = tree.find('.//business-hours')
				start = hours.find('start')
				self.businessHours['startHour'] = int(start.find('HH').text)
				self.businessHours['startMinute'] = int(start.find('MM').text)
				end = hours.find('end')
				self.businessHours['endHour'] = int(end.find('HH').text)
				self.businessHours['endMinute'] = int(end.find('MM').text) 

			# get google calendar api key if needed
			if not hasattr(self, 'self.api_key'):
				self.api_key = tree.find('.//calendar-api-key').text

			# get database path if needed
			if not hasattr(self, 'db_path'):
				self.db_path = tree.find('.//db-path').text

			# get list of usernames / calendar ID's
			if not hasattr(self, 'configUserList'):
				self.configUserList = []
				for user in tree.findall('.//user'):
					username = user.find('name').text
					calendarId = user.find('gmail').text
					self.configUserList.append([username, calendarId])

		# except ElementTree.ParseError as err:
			# self.error = ("Error reading config file: %s: %s" % (Scheduler.CONFIG_FILE_PATH, str(err)))
			# return -1
		except IOError as err:
			self.error = ("Error reading config file: %s: %s" % (Scheduler.CONFIG_FILE_PATH, err.args[1]))
			return -1
		except ValueError as err:
			self.error = ("Error reading config file: %s: %s" % (Scheduler.CONFIG_FILE_PATH, str(err)))
			return -1
		except Exception as err:
			self.error = ("Error reading config file: %s: %s" % (Scheduler.CONFIG_FILE_PATH, str(err)))
			return -1

		try:
			f.close()
		except IOError as err:
			self.error = ("Error closing config file: %s: %s" % (Scheduler.CONFIG_FILE_PATH, err.args[1]))
			return -1

		if self.configUserList == [] or self.configUserList == None:
			self.error = ("Error parsing config file: %s: No userlist found." % (Scheduler.CONFIG_FILE_PATH))
			return -1
		return 0


	def __init__(self):
		pass

	# Setter and getter methods 

	@property
	def validUsers(self):
		"""Returns list of valid users (instructors)

		Reads config file if necessary and returns list of users useful for 
		input validation in user interfaces.

		Args:
			None
		Returns:
			A python list of valid users (instructors)
			-1 on Error (sets Scheduler.error with error code)

		"""
		if not hasattr(self, 'configUserList'):
			if self.loadConfigData() == -1:
				return -1

		users = []
		for u in self.configUserList:
			users.append(u[0])
		return sorted(users)


	def params(self, data):
		"""Sets complete parameter data

		Passes complete set of parameters to class in the form of a python
		dictionary. Useful in testing, and convenient when parameter attributes 
		have been received as json in web requests.

		Args:
			data: A dictionary in the follwing format:
			{"userlist": [{"name": "blow, joe"}], 
			 "window": {"start": "08/05/2014 07:00", 
				 		"end": "08/05/2014 17:00"}}
		Returns:
			0 on success
			-1 on Error (sets Scheduler.error with error code)

		"""
		if data == None or data == {}:
			self.error = ("Error: parameters passed as None or empty")
			return -1
		self.paramData = data
		self.paramUsers = []
		try:
			# set window datetimes
			string = self.paramData['window']['start']
			start = datetime.strptime(string, Scheduler.TIME_FORMAT)
			self.startWindow = start
			string = self.paramData['window']['end']
			end = datetime.strptime(string, Scheduler.TIME_FORMAT)
			self.endWindow = end
			# set userlist
			for u in data['userlist']:
				self.paramUsers.append(u['name'])
		except (KeyError, ValueError) as err:
			self.error = ("Error: Missing or malformed parameter: %s" % str(err))
			return -1
		return 0


	def clearParams(self):
		"""Clears parameter attributes
		Args:
			None
		Returns:
			None
		"""
		self.paramData = None
		self.paramUsers = []
		self.paramStartWindow = None
		self.paramEndWindow = None


	def validateParams(self):
		"""Validates parameter atributes
		Checks for existence of all parameter attributes. Also checks to make
		sure end window is after start window.

		Args:
			None
		Returns:
			0 on success
			-1 on Error (sets Scheduler.error with error code)
		"""
		# check if userlist is empty or not exist.
		if (hasattr(self, 'paramUsers') and self.paramUsers == []) or \
		   not hasattr(self, 'paramUsers'):
			self.error = ("Error: Userlist parameter empty")
			return -1
		# check startWindow exists, is valid datetime object
		if not hasattr(self, 'paramStartWindow'):
			self.error = ("Error: Empty start window parameter")
			return -1
		if hasattr(self, 'paramStartWindow'):
			if not isinstance(self.paramStartWindow, datetime):
				self.error = ("Error: Invalid start window parameter")
		# check endWindow exists, is valid datetime object
		if not hasattr(self, 'paramEndWindow'):
			self.error = ("Error: Empty end window parameter")
			return -1
		if hasattr(self, 'paramEndWindow'):
			if not isinstance(self.paramEndWindow, datetime):
				self.error = ("Error: Invalid end window parameter")
				return -1
		# check that end window is later than start window
		if self.paramStartWindow > self.paramEndWindow:
			self.error = ("Error: Window start time later than end time")
			return -1
		return 0


	@property
	def users(self):
		"""Property to return list of users in parameters.

		Args:
			None
		Returns:
			List of users in passed into parameters.
		"""
		if hasattr(self, 'paramUsers'):
			return self.paramUsers
		else:
			return []


	@users.setter
	def users(self, u):
		"""Setter to pass list of users in parameters.

		Args:
			List of users
		Returns:
			None
		"""		
		self.paramUsers = u

	
	@property
	def addUser(self):
		pass

	
	@addUser.setter
	def addUser(self, u):
		"""Adds one user to parameters
		
		Args:
			String u: Username to add
		Returns:
			None
		"""			
		if hasattr(self, 'paramUsers'):		
			self.paramUsers.append(u)
		else:
			self.paramUsers = [u]

	
	def delUser(self, u):
		"""Deletes user from parameters
		
		Args:
			String u: Username to delete
		Returns:
			None
		"""			
		if hasattr(self, 'paramUsers'):
			if u in self.paramUsers:
				self.paramUsers.remove(a)

	
	@property
	def startWindow(self):
		"""returns start of time window
		
		Args:
			None
		Returns:
			datetime: start of time window
		"""			
		if hasattr(self, 'paramStartWindow'):
			return self.paramStartWindow
		else:
		 return None

	
	@startWindow.setter
	def startWindow(self, dt):
		"""Sets start of time window required in parameters
		
		Args:
			datetime object u: start of window
		Returns:
			None
		"""			
		self.paramStartWindow = dt

	
	@property
	def endWindow(self):
		"""returns end of time window
		
		Args:
			None
		Returns:
			datetime: end of time window
		"""		
		if hasattr(self, 'paramEndWindow'):
			return self.paramEndWindow
		else:
			return None


	@startWindow.setter
	def endWindow(self, dt):
		"""Sets start of time window required in parameters
		
		Args:
			datetime object u: start of window
		Returns:
			None
		"""			
		self.paramEndWindow = dt


	@property
	def solution(self):	
		"""Returns solution as dictionary

		Returns complete solution as well as parameters. Rather than calling
		Scheduler.times and Scheduler.Attendees seperately,  this will provide 
		both of these lists as well as the original paramters. provider.cgi 
		uses this to send the solution back as json data.
		
		Args:
			None
		Returns:
			dictionary solution: Complete solution as dictionary.
		"""	

		self.solutionData = {}

		# return empty solution if called before schedule()
		if not hasattr(self, 'solutionTimes') or \
		   not hasattr(self, 'solutionAttendees'):
			return {}

		userDict = []
		for u in self.paramUsers:
			userDict.append({"name": u})
		self.solutionData["userlist"] = userDict

		start = self.paramStartWindow.strftime(Scheduler.TIME_FORMAT)
		end = self.paramEndWindow.strftime(Scheduler.TIME_FORMAT)	
		self.solutionData["window"] = {"start": start, "end": end}

		timesDict = []
		for t in self.solutionTimes:
			start = t[0].strftime(Scheduler.TIME_FORMAT)
			end = t[1].strftime(Scheduler.TIME_FORMAT)
			timesDict.append({"start": start, "end": end})
		self.solutionData["times"] = timesDict

		attendeeDict = []
		for a in self.solutionAttendees:
			attendeeDict.append({"name": a})
		self.solutionData["attendees"] = attendeeDict

		return self.solutionData

	# returns all times within parameter window that all users are available
	@property
	def times(self):
		"""Returns list of times from solution
		
		Args:
			None
		Returns:
			list times: Times all given users are available
		"""	
		if hasattr(self, 'solutionTimes'):
			return self.solutionTimes
		else: 
			return []


	@property
	def attendees(self):
		"""Returns list of attendees from solution
		
		Args:
			None
		Returns:
			list attendees: Users who are available to meet throughout the given
					time period
		"""			
		if hasattr(self, 'solutionAttendees'):
			return self.solutionAttendees
		else: 
			return []


	def __solve(self, startWindow, endWindow, events):
		"""Uses data and parameters to return scheduling solution.

		Uses the data scraped from the OSU online schedule of classes and
		obtained from users' Google Calendars to produce a list of users able
		to attend meetings throughout the given time period and a list of time 
		windows (start, end) where all given users can attend.
		
		Args:
			datetime object startWindow: Start of given time window
			datetime object endWindow: End of given time window
			list events: List of windows representing time users are NOT
						 available. 
						 Each element is in the form [datetime, datetime, username]
		Returns:
			0 on success (sets self.solutionAttendees and self.solutionTimes)
		"""	
		START = 0
		END = 1
		NAME = 2
		windows = [[startWindow, endWindow]]
		# Get attendees - users available for entire initial window
		self.solutionAttendees = deepcopy(self.paramUsers)
		for e in events:
			for w in windows:
				# if portion of event falls within window, remove user from attendees
				if (e[START] >= w[START] and e[START] <= w[END]) or \
				   (e[END] >= w[START] and e[END] <= w[END]):
					if e[NAME] in self.solutionAttendees:
						self.solutionAttendees.remove(e[NAME])	

		# Calculate time windows. The algorithm works as follows. Start with a
		# windows list with one element, the given time window. Loop through all 
		# events, and compare the event to each window in the list of windows. 
		# The event either cuts the window off at the top or bottom, divides it
		# in two, or removes the window if it fits inside the event.
		for e in events:
			for index, w in enumerate(windows):
				# case 1 - event envelopes the entire window (remove window)
				if e[START] <= w[START] and e[END] >= w[END]:
					del windows[index]		
					continue
				# case 2 - event overlaps top end
				if (e[START] < w[END] and e[START] > w[START]) and \
				  e[END] >= w[END]:			  
					w[END] = e[START]		
					windows[index] = w
					continue

				if (e[END] > w[START] and e[END] < w[END]) and e[START] <= w[START]:
					w[START] = e[END]		
					windows[index] = w
					continue

				# case 4 - event inside window - split window in two
				if e[START] > w[START] and e[END] < w[END]:
					newHigh = [e[END], w[END]]
					newLow = [w[START], e[START]]
					windows[index] = newLow	
					windows.insert(index + 1, newHigh)
		self.solutionTimes = windows
		if windows == []:
			self.solutionAttendees = []
		return 0


	def createDB(self, db_name):
		"""Creates SQLite database

		Called when database doesn't exist. Creates SQLite database to store scraped data.

		Args:
			String: Path to new database
		Returns:
			0 on success
			-1 on error (sets Scheduler.error with error message)
		"""
		self.error = None
		try:
			con = lite.connect(self.db_path)
			cur = con.cursor()
			cur.execute("CREATE TABLE Faculty(Id INTEGER PRIMARY KEY, name TEXT, stamp TIMESTAMP);")
			cur.execute("CREATE TABLE Events(Id INTEGER PRIMARY KEY, start TIMESTAMP, end TIMESTAMP, name TEXT);")
		except (IOError, lite.OperationalError) as err:
			self.error = ("Error creating database: %s: %s" % (self.db_path, str(err)))
			return -1
		return 0


	def storeEvents(self, events, name):
		"""Stores scraped data

		Stores scraped data in SQLite database

		Args:
			List of events: Each element as [datetime, datetime, username] 
			String name: username
		Returns:
			0 on success
			-1 on error (sets Scheduler.error with error message)			
		"""
		try:
			con = lite.connect(self.db_path)
			cur = con.cursor()
			cur.execute("INSERT INTO Faculty VALUES(?, ?, ?)", (None, name, datetime.now()))
			for e in events:
				cur.execute("INSERT INTO Events VALUES(?, ?, ?, ?)", (None, e[0], e[1], e[2]))	
			con.commit()
		except (IOError, lite.OperationalError) as err:
			self.error = ("Error writing to database: %s: %s" % (self.db_path, str(err)))
			return -1
		finally:
			if con:
				con.close()
		return 0


	def getStoredEvents(self, name):
		"""Retrieves stored events

		Retrieves stored events from SQLite database. Any events that fall
		within or overlaps the given window are selected.

		Args:
			String name: username
		Returns:
			0 if no events are stored for given username
			List of events: Each element as [datetime, datetime, username] 
			-1 on error (sets Scheduler.error with error message)			
		"""
		try:
			con = lite.connect(self.db_path, detect_types=lite.PARSE_DECLTYPES)
			cur = con.cursor()
			cur.execute("SELECT 1 FROM Faculty WHERE name = ?", (name,))
			if not cur.fetchone():
				return 0
			cur.execute("SELECT * FROM Events WHERE name = ? AND \
				(start >= ? AND end <= ?) OR \
				(start >= ? AND start < ?) OR \
				(end <= ? AND end > ?)", 
				(name, self.paramStartWindow, self.paramEndWindow, \
				self.paramStartWindow, self.paramEndWindow, \
				self.paramEndWindow, self.paramStartWindow))
			events = cur.fetchall()
		except (IOError, lite.OperationalError) as err:
			self.error = ("Error reading database: %s: %s"	 % (self.db_path, str(err)))
			return -1
		if not events:
			return 1
		formattedEvents = []
		for e in events:
			formattedEvents.append([e[1], e[2], e[3]])
		return formattedEvents				


	# gathers data from DB, scraper, google calendars
	def schedule(self):
		"""Get schedule data and solution.

		Uses Scraper and CalendarTool to gather event data for each user, and
		Scheduler.solve to find the scheduling solution.

		Args:
			None (Member variables used)
		Returns:
			0 on success
			-1 on error (sets Scheduler.error with error message)			
		"""		
		if self.validateParams() == -1:
			return -1

		if not hasattr(self, 'configUserList'):
			if self.loadConfigData() == -1:
				return -1

		# Create DB if not exist
		if not isfile(self.db_path):
			if self.createDB(self.db_path) == -1:
				return -1

		self.allEvents = []

		# get google calendar data
		ct = CalendarTool()
		for name in self.paramUsers:
			# get calendar id
			calendarId = None
			for u in self.configUserList:
				if u[0] == name:
					calendarId = u[1]
					break
			if calendarId == None:
				self.error = ("Error: User not found: '%s'" % name)
				return -1			
			e = ct.events(self, name, calendarId, self.paramStartWindow, self.paramEndWindow, self.api_key)
			if e == -1:
				return -1
			if e == None:
				continue
			self.allEvents.extend(e)	

		#get scraped data
		s = Scraper()
		for name in self.paramUsers:

			# get data from DB if stored
			e = self.getStoredEvents(name)
			if e == -1:			# error reading DB
				return -1
			if e == 1:			# In DB, no events in window
				continue
			elif e != 0: 		# In DB, events in window to add
				self.allEvents.extend(e)
				continue
			# else scrape data
			if s.scrape(self, name) == -1:
				return -1
			e = s.getEvents()
			# store events in DB
			if self.storeEvents(e, name) == -1:
				return -1
			# add events to all
			self.allEvents.extend(e)

		# create after hours events for each day in window 
		# (limits solution to business hours indicated in config file)
		curr = deepcopy(self.paramStartWindow)
		try:	
			while curr <= self.paramEndWindow:
				AMEvent = [datetime(curr.year, curr.month, curr.day, 00, 00, 00),
					datetime(curr.year, curr.month, curr.day, 
					self.businessHours['startHour'], 
					self.businessHours['startMinute'], 00), " "]
				PMEvent = [datetime(curr.year, curr.month, curr.day, 
					self.businessHours['endHour'], 
					self.businessHours['endMinute'], 00),
					datetime(curr.year, curr.month, curr.day, 00, 00, 00)+timedelta(days=1), " "]
				self.allEvents.extend([AMEvent, PMEvent])
				curr = curr + timedelta(days=1)	
		except ValueError as err:
			self.error = ("Config file error: '%s': %s:" % (self.db_path, str(err)))
			return -1
		# get solutions
		self.__solve(self.paramStartWindow, self.paramEndWindow, self.allEvents)
		return 0
